# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""repository stuffs

Classes:
Repository   -- Repository context
ChangedPath  -- Representation of a changed path
Dirent       -- Wrapper around svn_ra_stat and svn_dirent_t
Revision     -- Container for metadata about a revision

TODO(epg): Callers should not be instantiating Dirent or Revision, but
instead using Repository.Stat and Repository.GetRevisions.  But the
class interfaces are definitely public.  Should these two classes be
prefixed with _ ?

"""


import os
import tempfile

import svn.core
import svn.ra

from svn.core import svn_node_none, svn_node_file, svn_node_dir
from svn.core import svn_node_unknown
from svn.core import SVN_ERR_FS_NOT_DIRECTORY, SVN_ERR_FS_NOT_FOUND
from svn.core import SubversionException

import gvn
import gvn.errors


class ChangedPath(object):
  """Representation of a changed path.

  Use the old_path, old_revision, path, and NewRevision properties
  together to diff or merge the change this represents.

  """

  def __init__(self, path, revision, action, copyfrom_path, copyfrom_rev,
               relative_path=None, source_path=None, source_revision=None):
    """Initialize ChangedPath from str, str, str, int [, str, str, int].

    Arguments:
    path            -- full path in the repository
    revision        -- revision number
    action          -- A(dded), D(eleted), M(odified), or R(eplaced)
    copyfrom_path   -- full path in the repository or None
    copyfrom_rev    -- revision number or -1

    Optional arguments for ChangeBranch.changed_paths:
    relative_path   -- project relative path
    source_path     -- full path to source branch
    source_revision -- revision of source branch

    """

    self._path = path
    self._revision = revision
    self._action = action
    self._copyfrom_path = copyfrom_path
    self._copyfrom_rev = copyfrom_rev
    self._relative_path = relative_path
    self._source_path = source_path
    self._source_revision = source_revision

  path = property(lambda self: self._path,
                  doc="""Real absolute path for this change.

  If this ChangedPath is part of a change branch, this is the
  absolute path in the source branch.

  """)
  action = property(lambda self: self._action,
                    doc="""A(dded), D(eleted), M(odified), or R(eplaced).""")
  copyfrom_path = property(lambda self: self._copyfrom_path)
  copyfrom_revision = property(lambda self: self._copyfrom_rev)
  relative_path = property(lambda self: self._relative_path,
                           doc="""Path relative to the project.

  Only available when part of a ChangeBranch.
  XXX More reason to think changebranch.py should have a subclass.

  """)
  revision = property(lambda self: self._revision)

  # Er, maybe we need a subclass for the changebranch stuff.
  def _GetOld(self):
    if self.copyfrom_path is not None:
      path = self.copyfrom_path
      revision = self.copyfrom_revision
    elif self._source_path is not None:
      path = self.source_path
      revision = self._source_revision
    else:
      path = self.path
      if self.action == 'A':
        revision = 0
      else:
        revision = self.revision - 1
    return (path, revision)
  old_path = property(lambda self: self._GetOld()[0])
  old_revision = property(lambda self: self._GetOld()[1])
  source_path = property(lambda self: '/'.join([self._source_path,
                                               self._relative_path]))


class Revision(object):
  """Container for metadata about a revision; also acts as dict of revprops.

  Revprop names (the keys) are utf-8 encoded str, and the values are
  str of arbitrary binary data.  Only callers know that some of them
  are text to be decoded (e.g. svn:{author,log}, which are
  utf-8-encoded).  This function could accept unicode *keys* and
  encode those, but since the caller must already decode values, it
  seems pointless.  See also gvn.commit.Drive.
  """

  def __init__(self, ra, paths, revision, properties={}, pool=None):
    """Initialize Revision from svn_ra_session_t, dict, int, dict, Pool.

    Arguments:
    ra         -- svn_ra_session_t
    paths      -- dict mapping path in the repository to svn_log_changed_path_t
    revision   -- number of this revision
    properties -- any properties of this revision the caller already knows
    pool       -- memory pool

    """

    self._ra = ra
    self._number = revision
    self._properties = dict(properties)
    self._pool = pool

    if paths is None:
      self._paths = []
    else:
      self._paths = [ChangedPath(path, self._number, entry.action,
                                 entry.copyfrom_path, entry.copyfrom_rev)
                     for (path, entry) in paths.iteritems()]

  def __contains__(self, key):
    self._GetProperties()
    return key in self._properties

  def __getitem__(self, key):
    try:
      return self._properties[key]
    except KeyError:
      self._GetProperties()
      return self._properties[key]

  def get(self, key, default=None):
    try:
      return self[key]
    except KeyError:
      return default

  number = property(lambda self: self._number)
  paths = property(lambda self: self._paths)

  # TODO(epg): Consider only going to the network once, not every time
  # a caller does '[someprop]' when someprop isn't set or 'someprop
  # in'.  Would need a .Flush() method.
  def _GetProperties(self):
    for (prop, val) in svn.ra.rev_proplist(self._ra, self.number,
                                           self._pool).iteritems():
      self._properties[prop] = val

  def SortPaths(self):
    """TODO(epg): Sort paths asciibetically, with cb deletion first if submit"""
    pass


def _Log(ra, paths, start, end, peg=None, limit=0,
         strict_node_history=False, revision_cache=None, pool=None):
  """Return a list of Revisions, possibly by asking ra for the log of paths.

  If revision_cache is provided, this will store Revisions there.  If
  any revision this finds is already in revision_cache, that Revision
  will be returned instead of a new one.

  For any revision, the same Revision object is returned every time.

  Arguments:
  paths               -- paths whose revisions to include; default all
  start               -- revision number to start log
  end                 -- revision number to end log
  peg                 -- peg revision for paths
  limit               -- return at most this many Revisions
  strict_node_history -- do not trace back to copyfrom
  revision_cache      -- dict mapping number to Revision
  pool                -- memory pool

  """

  if peg is None:
    peg = start
  if paths != ['']:
    paths = [svn.ra.get_locations(ra, x, peg, [start])[start].lstrip('/')
             for x in paths]

  if revision_cache is None:
    # Make a temporary one: not returned, soon to be garbage collected.
    revision_cache = {}

  result = []
  def receiver(paths, revnum, author, date, message, unused_pool):
    try:
      revision = revision_cache[revnum]
    except KeyError:
      revision = Revision(ra, paths, revnum,
                          {'svn:author': author,
                           'svn:date':   date,
                           'svn:log':    message},
                          pool)
      revision_cache[revnum] = revision
    result.append(revision)

  discover_changed_paths = True
  svn.ra.get_log(ra, paths, start, end, limit, discover_changed_paths,
                 strict_node_history, receiver, pool)

  return result


# XXX What on earth were we thinking?  Everywhere else, we use the svn
# data structures directly.  Get rid of this?
class Dirent(object):
  """Wrapper around svn_ra_stat and svn_dirent_t."""

  _kinds = {
    svn_node_none:    'none',
    svn_node_file:    'file',
    svn_node_dir:     'dir',
    svn_node_unknown: 'unknown',
    }

  def __init__(self, repository, path, peg, pool=None):
    """Initialize SvnDirent from svn_ra_stat(repository.ra, path, peg, pool).

    Arguments are self-explanatory.

    Raises:
    gvn.errors.RepoPath
    SubversionException.apr_err == ???
    ???

    """

    self._repository = repository
    self._revision = None

    try:
      self._dirent = svn.ra.stat(self._repository.ra, path, peg, pool)
    except SubversionException, e:
      if e.apr_err in [SVN_ERR_FS_NOT_DIRECTORY, SVN_ERR_FS_NOT_FOUND]:
        # Caller asked for /foo/bar where /foo is not a directory.
        self._dirent = None
      else:
        # Something broke.
        raise
    if self._dirent is None:
      raise gvn.errors.RepoPath(self._repository.URL(path), peg)

  def _GetLastChanged(self):
    # XXX This is bogus as hell; I doubt any users of this class need
    # this stupid thing to fetch the log.  But I this class's very
    # existence is probably bogus; why not use svn_dirent_t directly,
    # as we use all other svn types directly.
    if self._revision is None:
      # Yes, believe it or not, created_rev is in fact last changed rev.
      (self._revision,) = self._repository.GetRevisions(start=self._dirent.created_rev)
    return self._revision
  last_changed = property(_GetLastChanged)

  kind = property(lambda self: self._dirent.kind)
  kind_str = property(lambda self: self._kinds.get(self._dirent.kind,
                                                  'unknown'))
  size = property(lambda self: self._dirent.size)
  has_props = property(lambda self: self._dirent.has_props)

  def KindIsNone(self): return self._dirent.kind == svn_node_none
  def KindIsFile(self): return self._dirent.kind == svn_node_file
  def KindIsDir(self): return self._dirent.kind == svn_node_dir
  def KindIsUnknown(self):
    return self._dirent.kind not in [svn_node_none,
                                     svn_node_file,
                                     svn_node_dir]


class RaCallbacks(svn.ra.Callbacks):
  def SetAuthBaton(self, baton):
    self.auth_hack = baton
    self.auth_baton = self.auth_hack[-1]

  wc = None
  def SetWC(self, wc):
    self.wc = wc

  def open_tmp_file(self, pool):
    # The svn_swig_py_make_file wrapper is broken for both file descriptors
    # and files on Windows, so we'll return a path.
    if self.wc is None:
      (fd, fn) = tempfile.mkstemp()
    else:
      path = self.wc.AbsolutePath('/'.join([svn.wc.get_adm_dir(pool), 'tmp']))
      (fd, fn) = tempfile.mkstemp(dir=path)
    os.close(fd)
    return fn

  def get_wc_prop(self, path, name, pool):
    if self.wc is None:
      return None
    try:
      path = self.wc.LocalPath(path)
    except gvn.errors.PathNotChild:
      return None
    return svn.wc.prop_get(name, path, self.wc._adm_access, pool)

  def push_wc_prop(self, path, name, value, pool):
    if self.wc is None:
      return None
    try:
      path = self.wc.LocalPath(path)
    except gvn.errors.PathNotChild:
      return
    self.wc.WCPropSet(path, name, value)

  def get_client_string(self, pool):
    return 'gvn-' + gvn.VERSION


class Repository(object):
  def __init__(self, username, url, config, pool):
    """Initialize Repository from str, str, Config, Pool.

    url must be in canonical form (i.e. as returned by
    svn_path_canonicalize).

    """

    self._username = username
    self._url = url
    self._config = config
    self._pool = pool
    self._scratch_pool = svn.core.Pool(pool)

    self.ra_callbacks = RaCallbacks()
    self._ra = None

    self._revision_path_cache = {}
    self._revision_cache = {}

  have_root_url = property(lambda self: getattr(self, '_have_root_', False),
                           lambda self, v: setattr(self, '_have_root_', v),
doc="""Whether this Repository was created with the root URL.

       Defaults to False, meaning this object must contact the
       repository (hitting the network, for remote repositories) to
       learn if self.URL() is the repository root.
       """)
  username = property(lambda self: self._username)

  def OpenRa(self, url=None, pool=None):
    if url is None:
      url = self._url
    return svn.ra.open2(url.encode('utf8'), self.ra_callbacks,
                        self._config.svn_config_hash, pool)

  def _GetRa(self):
    if self._ra is None:
      self._ra = self.OpenRa(pool=self._pool)
      url = svn.ra.get_repos_root(self._ra, self._pool).decode('utf8')
      if url != self._url:
        self._url = url
        svn.ra.reparent(self._ra, self._url.encode('utf8'), self._pool)
    return self._ra
  ra = property(_GetRa)

  def URL(self, path=None):
    """Return URL of path, or repository root if path is unspecified."""

    # Make sure self._url is ready.
    if not self.have_root_url:
      self._GetRa()

    if path in [None, '']:
      return self._url
    return '/'.join([self._url, path])

  def ShortToLongURL(self, short_url):
    """Convert short_url to a fully qualified (long) URL.

    Returns a string with the leading '^/' replaced by the repository root
    URL or raises NotShortURL if 'short_url' does not begin with '^/'.

    For compatibility with older releases, treat // the same way.

    Raises:
      AttributeError  (if short_url is not a string)
      gvn.errors.NotShortURL  (if short_url is not in "short form")

    """

    if not short_url.startswith('^/') and not short_url.startswith('//'):
      raise gvn.errors.NotShortURL(short_url)

    if short_url in ['^/', '//']:
      return self.URL()

    return self.URL(short_url[2:])

  def LongToShortURL(self, long_url):
    """Convert a fully qualified (long) URL to a short URL.

    Returns a string with the leading repository root URL replaced by '^/'
    or raises PathNotChild if 'long_url' does not begin with the
    repository root.

    Raises:
      AttributeError  (if long_url is not a string)
      gvn.errors.PathNotChild  (if long_url does not begin with the
                                 repository root)
    """
    if long_url == self.URL():
      return '^/'

    if not long_url.startswith(self.URL() + '/'):
      raise gvn.errors.PathNotChild(long_url, self.URL())

    return '^/' + long_url[len(self.URL()):].lstrip('/')

  def GetHead(self, pool=None):
    """Return the HEAD revision number of this repository."""
    if pool is None:
      pool = self._pool
    return svn.ra.get_latest_revnum(self.ra, pool)

  def GetRevisions(self, paths=[''], start=None, end=None, peg=None, limit=0,
                   strict_node_history=False):
    """Return a list of Revisions, possibly by asking .ra for the log of paths.

    To get a Revision for a specific revision, specify only start; all
    other arguments are redundant.  Returned list is ordered from
    start to end regardless which is higher.

    For any revision, the same Revision object is returned every time.

    If this is called a second time with the same arguments, it won't
    hit the repository at all.

    Arguments:
    paths               -- paths whose revisions to include; default all
    start               -- revision number to start log
    end                 -- revision number to end log
    peg                 -- peg revision for paths
    limit               -- return at most this many Revisions
    strict_node_history -- do not trace back to copyfrom

    """

    path_cache_key = [peg, start, end, limit, strict_node_history]
    path_cache_key.extend(paths)
    path_cache_key = tuple(path_cache_key)
    try:
      hit = self._revision_path_cache[path_cache_key]
    except KeyError:
      hit = None
    if hit is not None:
      return hit

    if end is None:
      if start is None:
        start = self.GetHead()
        end = 1
      else:
        end = start

    result = _Log(self.ra, paths, start, end, peg, limit, strict_node_history,
                  self._revision_cache, self._scratch_pool)
    self._scratch_pool.clear()

    self._revision_path_cache[path_cache_key] = result
    return result

  def GetRevProp(self, revision, propname):
    return self.GetRevisions(start=revision)[0][propname]

  def Stat(self, path, peg):
    return Dirent(self, path, peg, svn.core.Pool(self._pool))
